● Zion Boggan
repos/Stepping App/apps/web/src/app/city/[slug]/videos/page.tsx
zionboggan.com ↗
179 lines · tsx
History for this file →
1
import Link from "next/link";
2
import { SiteFooter } from "@/components/site-footer";
3
import { notFound } from "next/navigation";
4
import { revalidatePath } from "next/cache";
5
import { VIDEO_PLATFORM } from "@stepping/types";
6
import { serverCaller } from "@/lib/trpc/server";
7
import { VideoEmbed } from "@/components/video-embed";
8
 
9
export const dynamic = "force-dynamic";
10
 
11
const PLATFORM_LABEL: Record<string, string> = {
12
  youtube: "YouTube",
13
  instagram: "Instagram",
14
  tiktok: "TikTok",
15
  other: "Other",
16
};
17
 
18
function youtubeId(url: string): string | null {
19
  try {
20
    const u = new URL(url);
21
    const host = u.hostname.replace(/^www\./, "");
22
    if (host === "youtu.be") {
23
      const id = u.pathname.slice(1);
24
      return /^[a-zA-Z0-9_-]{11}$/.test(id) ? id : null;
25
    }
26
    if (host === "youtube.com" || host === "m.youtube.com") {
27
      if (u.pathname === "/watch") {
28
        const id = u.searchParams.get("v");
29
        return id && /^[a-zA-Z0-9_-]{11}$/.test(id) ? id : null;
30
      }
31
      const embed = u.pathname.match(/^\/(?:embed|shorts)\/([a-zA-Z0-9_-]{11})/);
32
      if (embed) return embed[1];
33
    }
34
    return null;
35
  } catch {
36
    return null;
37
  }
38
}
39
 
40
export default async function VideosPage({
41
  params,
42
}: {
43
  params: Promise<{ slug: string }>;
44
}) {
45
  const { slug } = await params;
46
  const caller = serverCaller();
47
  const city = await caller.city.bySlug({ slug });
48
  if (!city) notFound();
49
 
50
  const videos = await caller.video.list({ cityId: city.id });
51
 
52
  async function addVideo(formData: FormData) {
53
    "use server";
54
    const title = String(formData.get("title") ?? "").trim();
55
    const embedUrl = String(formData.get("embedUrl") ?? "").trim();
56
    const platform = String(formData.get("platform") ?? "other");
57
    if (!title || !embedUrl) return;
58
    await serverCaller().video.create({
59
      cityId: city!.id,
60
      title,
61
      embedUrl,
62
      platform: platform as (typeof VIDEO_PLATFORM)[number],
63
    });
64
    revalidatePath(`/city/${slug}/videos`);
65
  }
66
 
67
  return (
68
    <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8">
69
      <Link href={`/city/${slug}`} className="kicker hover:text-gold">
70
        ← {city.name}
71
      </Link>
72
 
73
      <p className="kicker mt-10 text-ash">
74
        Tapes · {city.metroLabel}
75
      </p>
76
      <h1 className="display mt-3 text-7xl sm:text-8xl">The Tape</h1>
77
      <p className="serif mt-5 max-w-xl text-xl text-ash">
78
        Sets, classes, and footwork caught on camera across {city.name}.
79
      </p>
80
 
81
      {}
82
      <section className="mt-14 border-t border-[var(--line)] pt-10">
83
        <div className="flex items-end justify-between">
84
          <h2 className="display text-3xl">Add a clip</h2>
85
          <span className="kicker">Add to the tape</span>
86
        </div>
87
        <form action={addVideo} className="mt-6 flex flex-col gap-3">
88
          <input
89
            name="title"
90
            required
91
            placeholder="Clip title (e.g. Saturday Night Set 2025)"
92
            className="serif border-b border-[var(--line)] bg-transparent py-3 text-2xl text-bone outline-none placeholder:text-ash focus:border-gold"
93
          />
94
          <input
95
            name="embedUrl"
96
            required
97
            type="url"
98
            placeholder="Video URL (e.g. https://youtu.be/…)"
99
            className="serif border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold"
100
          />
101
          <div className="mt-2 flex flex-wrap gap-4">
102
            <select
103
              name="platform"
104
              defaultValue="youtube"
105
              className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold"
106
            >
107
              {VIDEO_PLATFORM.map((p) => (
108
                <option key={p} value={p} className="bg-stage">
109
                  {(PLATFORM_LABEL[p] ?? p).toUpperCase()}
110
                </option>
111
              ))}
112
            </select>
113
            <button
114
              type="submit"
115
              className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright"
116
            >
117
              Add clip
118
            </button>
119
          </div>
120
        </form>
121
      </section>
122
 
123
      {}
124
      <section className="mt-16 border-t border-[var(--line)] pt-10">
125
        <div className="flex items-end justify-between">
126
          <h2 className="display text-3xl">The Reel</h2>
127
          <span className="kicker text-ash">
128
            {String(videos.length).padStart(2, "0")} clips
129
          </span>
130
        </div>
131
 
132
        {videos.length === 0 ? (
133
          <p className="serif mt-8 text-xl text-ash">
134
            Clips will appear here as the community adds them. Add the first
135
            one above.
136
          </p>
137
        ) : (
138
          <ul className="mt-8 flex flex-col gap-12">
139
            {videos.map((v, i) => {
140
              const ytId =
141
                v.platform === "youtube" ? youtubeId(v.embedUrl) : null;
142
              return (
143
                <li key={v.id}>
144
                  <div className="flex items-end justify-between gap-5">
145
                    <div className="flex items-end gap-5">
146
                      <span className="kicker w-8 text-gold">
147
                        {String(i + 1).padStart(2, "0")}
148
                      </span>
149
                      <h3 className="display text-2xl sm:text-3xl">{v.title}</h3>
150
                    </div>
151
                    <span className="kicker border border-[var(--line)] px-3 py-1">
152
                      {(PLATFORM_LABEL[v.platform] ?? v.platform).toUpperCase()}
153
                    </span>
154
                  </div>
155
 
156
                  {ytId ? (
157
                    <div className="mt-5">
158
                      <VideoEmbed id={ytId} title={v.title} />
159
                    </div>
160
                  ) : null}
161
 
162
                  <a
163
                    href={v.embedUrl}
164
                    target="_blank"
165
                    rel="noopener noreferrer"
166
                    className="kicker mt-5 inline-block text-gold hover:text-gold-bright"
167
                  >
168
                    Watch →
169
                  </a>
170
                </li>
171
              );
172
            })}
173
          </ul>
174
        )}
175
      </section>
176
      <SiteFooter />
177
    </main>
178
  );
179
}